Skip to content

feat(be,fe): SSO sign-in via two-hop OIDC discovery#3785

Merged
aterga merged 15 commits intodfinity:mainfrom
timothyaterton:frontend-oidc-discovery
Apr 24, 2026
Merged

feat(be,fe): SSO sign-in via two-hop OIDC discovery#3785
aterga merged 15 commits intodfinity:mainfrom
timothyaterton:frontend-oidc-discovery

Conversation

@timothyaterton
Copy link
Copy Markdown
Contributor

@timothyaterton timothyaterton commented Apr 16, 2026

Problem

Internet Identity users can sign in with personal Google/Apple/Microsoft accounts, but there's no way for an organization to let their employees sign in with their own SSO domain (e.g. alice@dfinity.org) while still having II act as the identity provider. Each new organization would otherwise require a code change on II to add their issuer, client id, and OAuth endpoints.

Solution

A user types their organization's domain on the SSO screen; the frontend calls add_discoverable_oidc_config to register the domain (gated by the backend's canary allowlist), then runs a two-hop discovery chain to resolve the provider's OAuth endpoint, then redirects them to sign in.

Registration is user-initiated: the SSO screen itself drives the canister update call. An II admin still has to land a new domain in the backend's canary allowlist (openid::generic::allowed_discovery_domains()) via canister upgrade — what's new is that, once that's done, individual users can register their own org's domain via the SSO screen instead of the config living inline in II's init args.

The allowlist is gated on the deployment's is_production init flag so the two mainnet canisters don't share a domain: on id.ai only dfinity.org is accepted, on beta.id.ai (and everywhere else — staging, local, CI) only beta.dfinity.org. Keeping them disjoint means a DNS takeover of the beta test domain can't backdoor production, and we can stage new IdP wiring on beta.dfinity.org without risking the prod issuer.

Replaces #3786, which is closed.

Changes

Backend

Removed: oidc_configs from init args and synchronized config

InternetIdentityInit previously carried oidc_configs, but applying it had never been wired through apply_install_arg — it was silently dropped on install/upgrade. Removed the field entirely from InternetIdentityInit, InternetIdentitySynchronizedConfig, the Candid interface, config(), and the From<&InternetIdentityInit> for InternetIdentitySynchronizedConfig impl. Registration goes exclusively through the add_discoverable_oidc_config update call from here on, and /.config.did.bin only carries openid_configs (the direct Google/Apple/Microsoft configs).

add_discoverable_oidc_config

Traps for any domain not in ALLOWED_DISCOVERY_DOMAINS, otherwise inserts the domain into persistent state and kicks off a backend-side two-hop discovery (to populate JWKS for signature verification on subsequent sign-ins). The canister also exposes discovered_oidc_configs for querying resolved SSO provider state.

Frontend

Type alignment with backend. DiscoverableOidcConfig is { discovery_domain: string }.

Two-hop discovery (ssoDiscovery.ts, new).

  1. GET https://{domain}/.well-known/ii-openid-configuration returns { client_id, openid_configuration }. The domain owner publishes this at their DNS-backed origin.
  2. GET {openid_configuration} is the provider's standard OIDC discovery, yielding authorization_endpoint and scopes_supported.

Both hops run from the browser. (The backend has its own copy in openid/generic.rs; keeping the implementations separate for now minimizes BE↔FE synchronization.)

SSO flow UI.

  • SignInWithSso.svelte (new): domain input screen. Framed icon chip, title "Sign In With SSO", subtitle "Enter your company domain", placeholder "company.domain.com". Input triggers a debounced (200ms) lookup: anonymousActor.add_discoverable_oidc_config (idempotent; traps if the domain isn't on the backend allowlist) → discoverSsoConfig. The Continue button enables only once the lookup succeeds; clicking it opens the OAuth popup synchronously from the user gesture (critical for Safari). Canary-allowlist traps are mapped to "SSO is not available for \"<domain>\" yet." inline.
  • SsoIcon.svelte (new): key icon.
  • PickAuthenticationMethod.svelte: "Continue with passkey" is the top button; OIDC provider icons + SSO key icon render in the row below at equal width. The SSO icon is always rendered — users always see SSO as an option, regardless of whether any domain is registered.
  • authFlow.svelte.ts: new signInWithSso view state and continueWithSso() method.
  • Subtitle text across all three sign-in entry points updated from "Choose method to continue""Choose an authentication method to continue".

SSO vs direct-provider credential disambiguation.

An SSO credential can arrive via the same underlying IdP (e.g. Google) as a direct-Google credential. They differ in aud (the client_id), but two things broke simple issuer-only matching:

  1. Labeling an SSO-via-Google credential as "Google account" (it's not — it's the user's org account).
  2. Mis-labeling a legacy direct-Google credential as SSO after client_id rotation.

Resolved via three-tier lookup: strict (iss, aud) match → localStorage SSO-domain map ({iss, sub, aud} → domain) → issuer-only fallback. Credentials linked via SSO remember the typed domain in localStorage so they can be surfaced by domain in the access-methods UI on the same device. Cross-device labeling needs backend support and is tracked in #3795.

Safari popup. The OAuth popup must open synchronously from the click event or Safari blocks it. The SSO lookup (add_discoverable_oidc_config + two-hop) is done in a debounced input handler, stashed in local state, and consumed by the click handler with no intervening awaits before window.open.

Already-linked disambiguation. If the backend returns OpenIdCredentialAlreadyRegistered, the FE queries get_anchor_info and distinguishes "already linked to this identity" (specialized OpenIdCredentialAlreadyLinkedHereError) from "already linked to another identity" (the generic error).

Cleanup. oidc_configs removed from the frontend's BackendCanisterConfig decode schema (no remaining FE consumer after the refactor). Candid is forward-compatible, so the backend continues to accept old init-arg shapes that may have included oidc_configs.

Security.

  • Domain input DNS-format validated (length, label length, forbidden characters).
  • Backend canary allowlist (allowed_discovery_domains(), gated on is_production) is the sole source of truth for which domains can register. The frontend does not carry its own copy — the gate lives on the canister where a compromised device can't bypass it.
  • All three URLs (/.well-known/ii-openid-configuration, provider discovery, authorization endpoint) must be HTTPS.
  • Issuer and authorization_endpoint hostnames must match the openid_configuration hostname exactly or as a true subdomain (prevents a tampered provider-discovery doc from bouncing auth off-host after we've committed to a provider). endsWith alone would accept look-alikes like evildfinity.okta.com.
  • Second-hop trust is inherited from the first-hop canary allowlist: an attacker who can tamper with an II-approved domain's .well-known has already breached something more fundamental than any FE-side allowlist would catch, and the org knows its own IdP better than II does.
  • Per-domain rate limit (1 attempt per 10 min), max 2 concurrent discoveries, 4-hour cache per hop, exponential backoff, timeouts with clearTimeout in finally.

Tests

  • config/oidc_configs.rs covers both branches of the is_production-gated allowlist: default install rejects dfinity.org (and accepts beta.dfinity.org), is_production: Some(true) install rejects beta.dfinity.org (and accepts dfinity.org). should_coexist_with_openid_configs now queries SSO state via discovered_oidc_configs instead of the removed config().oidc_configs.
  • 23 unit tests in ssoDiscovery.test.ts.
  • 22 unit tests in openID.test.ts, including 4 for selectAuthScopes and the (iss, aud) strict-then-fallback resolution for direct-vs-SSO credentials.
  • cargo test -p internet_identity --bin internet_identity: 219/219 pass.
  • cargo clippy --all-targets -D warnings: clean.
  • npm run lint + svelte-check: clean.

< Previous PR

@aterga aterga force-pushed the frontend-oidc-discovery branch from 19f0eac to a9731ba Compare April 20, 2026 20:53
aterga added a commit to timothyaterton/internet-identity that referenced this pull request Apr 20, 2026
…ecurity (dfinity#3784)

## Summary

Add the `aud` (audience / client_id) field to `OpenIdCredentialKey`,
changing it from `(iss, sub)` to `(iss, sub, aud)`. This is a security
prerequisite for SSO: since SSO allows anyone to provide a `client_id`
via their `ii-openid-configuration` endpoint, without `aud` in the key
two different OIDC clients at the same provider with the same user `sub`
would collide, enabling impersonation.

## Changes

- **Type update**: `OpenIdCredentialKey` type alias changed from `(Iss,
Sub)` to `(Iss, Sub, Aud)` in both `internet_identity_interface` and the
`openid` module
- **CBOR encoding**: `StorableOpenIdCredentialKey` rewritten with manual
`Encode`/`Decode` impls — new entries use CBOR map format `{0:iss,
1:sub, 2:aud}`; the decoder also handles legacy CBOR array format `[iss,
sub]` for backward compatibility
- **Migration**: `post_upgrade` drains the credential key index via
`pop_first`, resolves `aud` from each anchor's
`StorableOpenIdCredential` (which already stores `aud` at CBOR index
`#[n(2)]`), and re-inserts with the complete `(iss, sub, aud)` key.
Unresolvable entries are preserved with empty `aud` for retry on next
upgrade.
- **Key construction**: Updated `OpenIdCredential::key()`,
`StorableOpenIdCredential::key()`, `calculate_delegation_seed()`, and
all call sites
- **Candid interface**: Updated `.did` file and generated JS/TS
declarations
- **Frontend**: Updated credential removal call to pass `aud`
- **Tests**: Added unit tests for new CBOR map encoding, legacy array
decoding, and round-trip serialization. Updated existing test assertions
to use 3-tuple keys.

## Delegation seed backward compatibility

The `calculate_delegation_seed` function already receives `client_id`
(which equals `aud`) as a separate parameter. The seed calculation is
unchanged — `aud` from the key tuple is ignored (`_aud`) in the
destructuring, preserving identical `Principal` derivation for existing
credentials.

## Migration safety

- Uses `pop_first()` to drain the BTreeMap, avoiding byte-level encoding
mismatches between legacy array-encoded keys and new map-encoded keys
- Resolves `aud` from the anchor's stored `StorableOpenIdCredential`
which already has `aud` at CBOR index 2
- Falls back to re-inserting with empty `aud` if resolution fails, with
a logged warning — the entry is preserved for retry on next upgrade
- Idempotent: safe to run on every upgrade; entries already in the new
format are preserved unchanged

## Test plan

- [x] All 209 unit tests pass (including Candid interface compatibility)
- [ ] Integration tests (require canister WASM build — pass in CI)
- [ ] Deploy to testnet and verify migration of existing credentials
- [ ] Verify credential lookup works after migration
- [ ] Verify new credential registration includes `aud` in key

---
[< Previous PR](dfinity#3778)
| [Next PR >](dfinity#3785)

---------

Co-authored-by: Claude Agent <noreply@anthropic.com>
Co-authored-by: Arshavir Ter-Gabrielyan <arshavir.ter.gabrielyan@dfinity.org>
@aterga aterga marked this pull request as ready for review April 20, 2026 20:55
@aterga aterga requested a review from a team as a code owner April 20, 2026 20:55
Copilot AI review requested due to automatic review settings April 20, 2026 20:55
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds frontend-side OIDC discovery for a new “discoverable” provider configuration list (oidc_configs), enabling the UI/auth flow to fetch provider discovery documents on demand (instead of relying solely on backend-provided openid_configs).

Changes:

  • Extend backend config decoding/types to include oidc_configs (discoverable providers with discovery_url + optional client_id).
  • Add oidcDiscovery.ts with caching/rate limiting/validation and integrate it into auth + OpenID config lookup.
  • Render additional provider buttons for oidc_configs and add unit tests for discovery + config lookup.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
src/frontend/src/lib/utils/openID.ts Extend findConfig() to synthesize an OpenIdConfig from cached OIDC discovery + oidc_configs.
src/frontend/src/lib/utils/openID.test.ts Add tests covering findConfig() behavior with oidc_configs and discovery cache mocking.
src/frontend/src/lib/utils/oidcDiscovery.ts New module implementing OIDC discovery fetch, validation, caching, concurrency + rate limiting.
src/frontend/src/lib/utils/oidcDiscovery.test.ts New unit tests for discovery fetch, caching, and validation behavior.
src/frontend/src/lib/globals.ts Add candid IDL + TS types to decode oidc_configs from .config.did.bin.
src/frontend/src/lib/flows/authFlow.svelte.ts Add continueWithOidc() that fetches discovery and reuses the existing OpenID flow.
src/frontend/src/lib/components/wizards/auth/views/PickAuthenticationMethod.svelte Render provider buttons for oidc_configs and wire them to a new handler.
src/frontend/src/lib/components/wizards/auth/AuthWizard.svelte Wire continueWithOidc handler into the auth wizard flow.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/frontend/src/lib/utils/oidcDiscovery.ts Outdated
Comment thread src/frontend/src/lib/flows/authFlow.svelte.ts Outdated
Comment thread src/frontend/src/lib/flows/authFlow.svelte.ts Outdated
Comment thread src/frontend/src/lib/utils/openID.ts Outdated
Comment thread src/frontend/src/lib/utils/oidcDiscovery.ts Outdated
Comment thread src/frontend/src/lib/utils/oidcDiscovery.ts Outdated
@timothyaterton timothyaterton changed the title feat: frontend OIDC discovery for on-demand provider config feat(fe): OIDC discovery for on-demand provider config Apr 20, 2026
@aterga aterga requested review from Copilot and sea-snake April 20, 2026 21:34
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated 4 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/frontend/src/lib/utils/oidcDiscovery.ts Outdated
Comment thread src/frontend/src/lib/utils/openID.ts Outdated
Comment thread src/frontend/src/lib/utils/oidcDiscovery.ts Outdated
@aterga aterga force-pushed the frontend-oidc-discovery branch from 0ebd6e2 to 3d587a1 Compare April 20, 2026 22:10
@timothyaterton timothyaterton changed the title feat(fe): OIDC discovery for on-demand provider config feat(fe): SSO sign-in via two-hop OIDC discovery Apr 20, 2026
@aterga aterga force-pushed the frontend-oidc-discovery branch 2 times, most recently from 41c03a6 to 815d71e Compare April 20, 2026 23:56
@aterga aterga changed the title feat(fe): SSO sign-in via two-hop OIDC discovery feat(be,fe): SSO sign-in via two-hop OIDC discovery Apr 20, 2026
@aterga aterga force-pushed the frontend-oidc-discovery branch 8 times, most recently from 7a63b3d to 8774fda Compare April 21, 2026 16:10
Comment thread src/frontend/src/lib/utils/openID.test.ts Outdated
aterga and others added 4 commits April 22, 2026 22:42
Address review feedback on dfinity#3785:

- **`selectAuthScopes` always includes `openid`** (required by OIDC
  spec), regardless of what the provider advertises. Non-spec compliant
  providers that omit `openid` from `scopes_supported` still work.
- **Trim `TRUSTED_PROVIDER_DOMAINS` to only `dfinity.okta.com`.** Google
  / Apple / Microsoft are served via `openid_configs` (not SSO);
  `login.dfinity.org` was a hallucination.
- **Zod schemas for wire data** (`ii-openid-configuration`,
  OIDC discovery docs) replace hand-rolled structural checks.
- **Drop `oidc_configs` from `InternetIdentitySynchronizedConfig`**
  (Rust struct + Candid + FE decode). The frontend doesn't need a
  pre-baked SSO allowlist — the SSO screen calls
  `add_discoverable_oidc_config` on submit, so wire-shipping registered
  domains served no consumer.
- **Drop stale `oidc_configs: []` from the `backendCanisterConfig` test
  mock** in `openID.test.ts`.
- **Revert out-of-scope subtitle wording changes** ("Choose an
  authentication method to continue" → "Choose method to continue")
  on all three sign-in entry points; re-extract locales accordingly.
- Test assertions updated to match zod's error-message format.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`TRUSTED_PROVIDER_DOMAINS` on the frontend was redundant with the
backend's canary allowlist (`ALLOWED_DISCOVERY_DOMAINS`):

- A domain can only reach `discoverSsoConfig` after
  `add_discoverable_oidc_config` succeeded on the backend, which traps
  for domains not on the admin-vetted canary list.
- Once we've agreed that `dfinity.org` is trustworthy, whatever they
  publish at `/.well-known/ii-openid-configuration` represents their
  IdP choice — the org knows their own provider better than II does.
- An attacker who can tamper with a trusted domain's `.well-known`
  has already broken something more fundamental than the second-hop
  allowlist would protect against.

Removed `TRUSTED_PROVIDER_DOMAINS` and the matching check in
`validateProviderUrl`. Retained checks that still carry their weight:

- HTTPS on every URL in the chain.
- Issuer hostname / authorization_endpoint hostname must match the
  `openid_configuration` hostname (prevents a tampered provider-
  discovery doc from bouncing auth off-host after we've committed to
  a provider).
- Domain format validation on user input.

Updated the module docstring to spell out where the first-hop trust
actually comes from, and dropped the now-obsolete
"rejects untrusted provider domains" test.

Also means per-org onboarding no longer needs an II frontend deploy —
a new SSO org just needs the admin call to `add_discoverable_oidc_config`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Frontend error mapping in SignInWithSso now distinguishes each distinct
failure mode of the two-hop chain and includes the underlying detail
where it is actionable:

- HTTP-error from hop 1 → "X didn't serve /.well-known/...-configuration
  (HTTP 404). The domain owner needs to publish it for II to sign you
  in."
- Network failure from hop 1 → "Couldn't reach X. Check the spelling
  and your network, then try again."
- Malformed hop-1 response → includes the zod error detail (e.g.
  "expected string, received undefined at client_id") or the HTTPS
  check message.
- Hop-2 hostname mismatches (issuer / auth endpoint) → short human
  summary + the raw message for context.
- Canary-allowlist trap → "Ask an II admin to register this domain."
- Rate-limit / concurrency errors → friendly paraphrase.

`DomainNotConfiguredError.detail` is now a public field so the UI can
read the inner error text instead of collapsing to "invalid response".

---

Also temporarily adds `s4i6f-riaaa-aaaad-agnna-cai.icp0.io` to
`ALLOWED_DISCOVERY_DOMAINS` alongside `dfinity.org`, so the two-hop
flow can be exercised end-to-end from a test canister on staging-C.

DO NOT MERGE: the test entry must be removed before this PR lands on
main. The production canary allowlist stays at just `dfinity.org`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
An SSO-linked access method (e.g. via `dfinity.org`'s Okta) was being
rendered as "Google account" with the Google logo because
`findConfig(iss, metadata)` matched on issuer alone. When the
underlying IdP happens to be Google, the credential's `iss` is the
Google issuer and collides with the direct "Sign in with Google"
entry in `openid_configs` — even though the `aud` (client_id) is
completely different.

Changes:

- `findConfig(iss, aud, metadata)` now matches on both `iss` and `aud`
  (the OAuth client_id). `aud` is accepted as `string | undefined`;
  callers that haven't been extended to track `aud` yet
  (`LastUsedIdentity`-based paths — see dfinity#3795) keep their issuer-only
  behavior for now, which is correct for direct providers and
  mis-attributes SSO credentials at those legacy sites only.

- `openIdName(iss, sub, aud, metadata)` now also consults a per-device
  localStorage map (`ssoDomainStorage.ts`) populated at SSO link time.
  If the credential was linked via SSO on this device, the label
  becomes the `discovery_domain` the user typed (e.g. "dfinity.org
  account").

- `openIdLogo(iss, aud, metadata)` returns `undefined` for credentials
  that don't match any direct provider — the access-methods UI uses
  the generic SSO key icon as a fallback in that case.

- `SsoDiscoveryResult` grows a `domain` field; both sign-in and
  add-access-method flows persist the `(iss, sub, aud) → domain`
  mapping after a successful SSO link, so the next render shows the
  correct label.

- `OpenIdItem` renders the `<SsoIcon>` when `openIdLogo` returns
  undefined, and falls back to the literal "SSO" word when no domain
  is stored (cross-device case).

Cross-device labelling (credential linked on device A, viewed on
device B) remains wrong until the `discovery_domain` is persisted on
the backend credential — tracked in dfinity#3795.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@aterga aterga force-pushed the frontend-oidc-discovery branch from 9a2518f to 6269805 Compare April 22, 2026 20:47
aterga and others added 2 commits April 22, 2026 22:59
My earlier `findConfig` change required a strict (iss, aud) match and
returned undefined otherwise, falling through to a generic SSO render.
That broke the inverse of the original bug: a direct-Google credential
whose stored `aud` doesn't line up with the current `openid_configs`
entry (e.g. after a client_id rotation) gets rendered as "SSO account"
with the key icon, erasing its real provenance.

Restructure the resolution so both kinds of credential land correctly:

- `findConfig(iss, aud, metadata)` now does strict-then-fallback:
  1. Exact `(iss, aud)` match — authoritative.
  2. Issuer-only match — covers legacy / rotated / migration-artifact
     credentials so they still get the right provider label.

- `openIdName` / `openIdLogo` consult the per-device SSO map
  (`ssoDomainStorage`) BEFORE calling `findConfig`. SSO credentials
  linked on this device short-circuit to the SSO branch regardless of
  what `findConfig` would return; everything else gets
  `findConfig`'s strict-then-fallback answer.

Net behaviour:

- Direct Google, normal: strict match → "Google" + Google logo. ✓
- Direct Google, aud mismatch (legacy / rotation): issuer-only
  fallback → "Google" + Google logo. ✓ (was broken before)
- SSO-via-Google linked on this device: localStorage hit → domain
  name + `SsoIcon`. ✓
- SSO-via-Google on a different device: falls through to issuer-only
  → "Google" (mis-attributed, same as pre-PR behaviour). Needs the
  backend to persist `discovery_domain` on the credential, tracked in
  dfinity#3795.

Also threads `sub` into `openIdLogo` so the SSO short-circuit can
check the full `(iss, sub, aud)` key.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`openid_credential_add` rejects with `OpenIdCredentialAlreadyRegistered`
for both the case where the `(iss, sub, aud)` is attached to THIS
identity and the case where it's on a DIFFERENT one. The UI used the
generic "already linked to another identity" toast for both, which is
misleading when the credential is in fact already on the identity the
user is managing.

Distinguish the two on the client:

- `addAccessMethodFlow.linkOpenIdAccount` now catches the
  `OpenIdCredentialAlreadyRegistered` error, queries `get_anchor_info`
  for the current identity, and checks whether `(iss, sub, aud)` is
  already in `openid_credentials`.
- If yes, rethrow as the new `OpenIdCredentialAlreadyLinkedHereError`
  (plain JS Error, not a canister error).
- If no, re-throw the original canister error so it hits the existing
  "another identity" handler unchanged.

`error.ts` gains a matching branch that shows "This account is already
linked to this identity" for the new class; the original message is
preserved for the truly-different-identity case.

Covers both `linkOpenIdAccount` and `linkSsoAccount` since the latter
delegates to the former.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 38 out of 40 changed files in this pull request and generated 16 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/internet_identity/src/openid/generic.rs Outdated
Comment thread src/frontend/src/lib/locales/id.po Outdated
Comment thread src/frontend/src/lib/locales/fr.po Outdated
Comment thread src/frontend/src/lib/utils/ssoDiscovery.ts
Comment thread src/frontend/src/lib/locales/it.po Outdated
Comment thread src/frontend/src/lib/locales/es.po Outdated
Comment thread src/internet_identity_interface/src/internet_identity/types.rs
Comment thread src/frontend/src/lib/locales/ru.po Outdated
aterga and others added 6 commits April 23, 2026 12:16
The SSO screen was awaiting `add_discoverable_oidc_config` and
`discoverSsoConfig` inside the Continue click handler, then handing
off to `continueWithSso` which opens the OAuth popup. Safari blocks
`window.open` when it follows any `await` after the user event —
so the first click reliably failed on Safari, the pop-up never opened,
and the user saw the Signing-in spinner go nowhere.

Move the network-heavy lookup into a debounced input handler:

- 200 ms after the user stops typing, the input handler runs
  `add_discoverable_oidc_config` + `discoverSsoConfig` and stashes the
  result in `preparedResult`. The button reports "Checking…" while
  this is in flight.
- `preparedResult` drives the Continue button's enabled state: it
  lights up only once discovery is complete for the currently-typed
  domain.
- The click handler does no awaiting before `continueWithSso`, so the
  chain `click → handleSubmit → continueWithSso → requestJWT →
  requestWithPopup → redirectInPopup → window.open` is fully
  synchronous. Safari allows the popup.

Races: the typed input may change while a lookup is in flight. We
compare the lookup's `trimmed` against the current `domain.trim()
.toLowerCase()` before applying the result or the error so a stale
response can't clobber a fresher one. `invalidatePrepared()` on every
input cancels the debounce and the button goes back to disabled.

Format validation still runs synchronously on input (no round-trip),
and the error is only surfaced once the input looks like a complete
domain (contains a dot) — so the user doesn't see "Invalid domain
format" while still mid-typing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The direct-OpenID resume path in `authorize/+page.svelte` still called
`findConfig(iss, metadata)` — the legacy 2-arg signature. After
`findConfig` was updated to `(iss, aud, metadata)` for the SSO
credential-disambiguation change, this call stopped compiling (svelte-
check flagged "Expected 3 arguments, but got 2").

Destructure `aud` out of `decodeJWT(jwt)` and pass it through so the
strict-then-fallback resolution runs the same way as everywhere else.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The earlier `chore(fe): sync auto-extracted locale catalogs` commit
picked up the new "Checking..." / "Sign In With SSO" / "Company domain"
msgids, but also cleared the existing translations of "Choose method
to continue" in 10 non-English catalogs. That msgid is still used by
`/+page.svelte`, so the regression would have fallen back to English
at runtime.

Restore the prior translations from `origin/main` for de, es, fr, id,
it, nl, pl, ru, uk, ur.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- ssoDiscovery.ts header: drop the stale claim that callers must
  independently check the domain against a backend list. The actual
  gating is `add_discoverable_oidc_config` trapping on the canary
  allowlist before `discoverSsoConfig` runs — state that directly.
- PickAuthenticationMethod.svelte: the comment next to the SSO entry
  still claimed the SignInWithSso screen would reject every input with
  "This domain is not registered…", but rejection lives on the canister
  now. Describe the real behavior.
- SignInWithSso.svelte: the submit `<Button>` had both `type="submit"`
  and `onclick={handleSubmit}` while the surrounding `<form>` also
  calls `handleSubmit` in `onsubmit`. Clicking the button fired the
  handler twice. Drop the onclick — the form's onsubmit covers both
  Enter-in-input and button-click, and it still runs synchronously
  from the trusted user gesture (critical for the Safari popup).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The SSO canary allowlist used to be a single const with both the real
prod domain (`dfinity.org`) and a staging test canister host wearing a
`DO NOT MERGE` sticker. Replace the const with
`allowed_discovery_domains()`, which reads `is_production` from the
persistent state and branches:

- `is_production == Some(true)` (id.ai)       → `dfinity.org`
- otherwise (beta.id.ai, staging, local, CI)  → `beta.dfinity.org`

Keeping the two disjoint means a DNS takeover of the beta test domain
can't backdoor the production canister, and it lets us stage new IdP
registrations on beta without touching the production issuer. Also
removes the `DO NOT MERGE` staging-C entry the earlier commit carried.

Tests:
- `should_add_oidc_config_via_update_call`, `should_coexist_with_
  openid_configs`, `should_deduplicate_oidc_configs` switched to the
  default (non-prod) install and the beta domain.
- `should_reject_disallowed_discovery_domain` extended to assert that
  the production domain is rejected on a non-prod canister.
- New `should_allow_only_production_domain_on_production_canister`
  installs with `is_production: Some(true)` and asserts that only
  `dfinity.org` is accepted.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…received"

When a user tries to sign in via SSO and the provider's OAuth app is
misconfigured — most often the Okta/Auth0/etc. app is set to
`response_types=[code]` only, and refuses our `response_type=id_token
code` hybrid request — the provider redirects back to II's callback
with something like:

  #state=…&error=unsupported_response_type
    &error_description=The+response+type+is+not+supported…

`requestWithPopup` used to just check for `id_token`, find it missing,
and throw `Error("No token received")`. That looks exactly like a bug
in II, sending admins down the wrong rabbit hole — the real fix is a
knob in the SSO app.

Changes:
- New `OAuthProviderError` in `openID.ts` carrying `error` +
  `errorDescription` separately (RFC 6749 §4.1.2.1 / 4.2.2.1 fields).
- New `extractIdTokenFromCallback(callback, expectedState)` helper:
  single source of truth for how we interpret a callback URL fragment,
  and the unit-testable seam. It throws `OAuthProviderError` BEFORE
  the `id_token` null-check, so a provider-side failure wins over the
  generic fallback. `requestWithPopup` now delegates to it.
- `SignInWithSso.svelte#mapSubmitError` specializes
  `unsupported_response_type` and `access_denied`, and otherwise falls
  back to `{error}: {error_description}` — all interpolated with the
  typed domain so the user knows which SSO they're debugging.
- `handleError` in `error.ts` handles `OAuthProviderError` for callers
  that route through the generic error sink (direct-OpenID entry
  points, not just SSO).

Tests:
- 6 new unit tests for `extractIdTokenFromCallback`: success, provider
  error with and without description, state mismatch (including a
  "forged-error" CSRF-shaped case where state is checked before error
  so an attacker can't drive the UI via a crafted fragment), missing
  state, and the "neither id_token nor error" fallback.
- 3 new unit tests for `OAuthProviderError`: message with/without
  description, and `instanceof Error` (so existing catch blocks still
  see it).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@aterga aterga enabled auto-merge April 24, 2026 09:23
Comment thread src/frontend/src/lib/flows/addAccessMethodFlow.svelte.ts
Comment thread src/frontend/src/lib/utils/ssoDomainStorage.ts
Copy link
Copy Markdown
Contributor

@sea-snake sea-snake left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mostly nit comments, one non-nit comment that can be handled in a separate PR.

@aterga aterga added this pull request to the merge queue Apr 24, 2026
Merged via the queue into dfinity:main with commit 765596d Apr 24, 2026
43 checks passed
aterga added a commit to aterga/internet-identity that referenced this pull request Apr 24, 2026
The SSO entry in PickAuthenticationMethod and AddAccessMethod leads
into a flow that can end in either a sign-in or a sign-up, so "Sign
in with SSO" is misleading. Rename the `aria-label` to "Continue with
SSO" — matches the copy pattern used by the direct-provider buttons
and the Continue button inside the SSO screen itself. The SignInWithSso
screen's h1 is updated separately in the following commit.

Addresses sea-snake's review comment on dfinity#3785.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
aterga added a commit to aterga/internet-identity that referenced this pull request Apr 24, 2026
Four related fixes from sea-snake's review, best reviewed together
since they overlap on the same files:

1. **Slim SSO error mapping to user-actionable copy.** `mapSubmitError`
   in `SignInWithSso.svelte` had a ladder of branches for every
   provider-misconfiguration shape (hostname mismatch, non-HTTPS
   issuer / auth endpoint, malformed discovery doc, ...). These
   read as implementation-detail leakage to end users. Keep only the
   cases the user (or their SSO admin) can act on — domain-not-
   configured, canary allowlist, OAuth provider errors from the
   callback fragment, rate limits — and fall back to a generic
   "SSO sign-in for <domain> failed" for everything else. Always
   `console.error` the raw error so engineers still have the stack.

2. **Disable Continue when the SSO is already linked to this
   identity.** `SignInWithSso.svelte` takes an optional
   `openIdCredentials` prop (passed in the add-access-method context,
   left `undefined` in the sign-in context). After two-hop discovery
   reveals the SSO's `(iss, aud)`, a `$derived` `isAlreadyLinked`
   checks against the prop and disables the Continue button with
   an inline hint. Mirrors the direct-provider (Google/Apple/...)
   button-disable in `AddAccessMethod.svelte` — reaching the
   canister's `OpenIdCredentialAlreadyRegistered` for this identity
   is no longer possible, so the `OpenIdCredentialAlreadyLinkedHereError`
   specialization in `linkOpenIdAccount` / `handleError` is removed
   as unreachable.

3. **Move SSO domain + name from FE localStorage to canister-stamped
   metadata.** The previous iteration used `ssoDomainStorage.ts` (a
   per-device localStorage map of `(iss, sub, aud) → domain`) to
   label SSO credentials by the domain the user typed. That's
   unreliable across devices and a lot of FE bookkeeping for something
   the canister already has. The canister now stamps two new metadata
   keys on any credential verified by a `DiscoverableProvider`:

   - `sso_domain` — the `discovery_domain` the user entered
     (canonical SSO label; always present for SSO credentials).
   - `sso_name` — the `name` field from
     `{domain}/.well-known/ii-openid-configuration` if the domain
     publishes one (e.g. `"DFINITY"`). Optional.

   The `IIOpenIdConfiguration` hop-1 schema gets a new optional
   `name: Option<String>` (serde `#[serde(default)]` so older
   deployments that don't publish the field still parse).
   `DiscoverableProvider` / `DiscoveryState` carry `discovery_domain`
   and a `discovered_name` ref that hop-1 populates each refresh.

4. **Render the SSO `name` (with domain fallback).** `openIdName`
   now reads `sso_name` → `sso_domain` → `findConfig(iss, aud).name`,
   so an SSO credential linked via a domain that publishes
   `name: "DFINITY"` renders as "DFINITY", and one that doesn't
   renders as "dfinity.org". `openIdLogo` returns `undefined` when
   `sso_domain` is present (generic SSO icon). `OpenIdItem.svelte`
   detects SSO via `sso_domain` directly instead of the "no logo
   found" heuristic — which was brittle for direct-provider credentials
   whose issuer didn't match any `openid_configs` entry.

Removes `ssoDomainStorage.ts` and its callers in
`authFlow.svelte.ts` and `addAccessMethodFlow.svelte.ts`. The SSO
sign-in flow no longer needs to pre-decode the JWT either — the
canister handles labeling — so `continueWithSso` collapses to
`return this.continueWithOpenId(syntheticConfig)`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
pull Bot pushed a commit to mikeyhodl/internet-identity that referenced this pull request Apr 24, 2026
…finity#3803)

Follow-up to dfinity#3785 addressing [@sea-snake's
review](dfinity#3785 (review)).
All five unresolved threads there are addressed here, and will be marked
resolved on dfinity#3785 with pointers back to this PR.

Stacked on top of dfinity#3785: this branch includes all commits from dfinity#3785
plus three new commits that cover the review fixes.

# Changes

## 1. `Sign in with SSO` → `Continue with SSO`

SSO button `aria-label` in `PickAuthenticationMethod` and
`AddAccessMethod`, and the h1 inside the SSO screen, now read "Continue
with SSO" — the downstream flow can end in either a sign-in or a
sign-up, so "Sign in" was misleading.

## 2. Slim `mapSubmitError` to user-actionable copy

`SignInWithSso.svelte#mapSubmitError` used to enumerate every shape of
provider misconfiguration (hostname mismatch, non-HTTPS endpoints,
malformed discovery document, ...). Those read as implementation-detail
leakage. Keep only the branches the user or their SSO admin can act on —
domain not configured, canary allowlist, OAuth provider errors from the
callback fragment, rate limits — and fall back to a generic `"SSO
sign-in for <domain> failed"` for everything else. Every thrown error is
`console.error`'d unconditionally so engineers still have the stack.

## 3. Disable Continue when the SSO is already linked to this identity

`SignInWithSso` takes a new optional `openIdCredentials` prop. In the
add-access-method flow, `AddAccessMethodWizard` passes the identity's
current credentials through; once two-hop discovery reveals the SSO's
`(iss, aud)`, a `$derived` check disables the Continue button with an
inline hint if that SSO is already linked. Mirrors how
`AddAccessMethod.svelte` already disables direct-provider buttons for
already-linked providers. Left `undefined` in the sign-in flow, where
reusing an existing credential is the point.

This makes reaching `OpenIdCredentialAlreadyRegistered` for *this*
identity impossible, so the `OpenIdCredentialAlreadyLinkedHereError`
specialization in `linkOpenIdAccount` and the corresponding toaster
branch in `handleError` are dropped as unreachable.

## 4 + 5. Move SSO domain + name from FE localStorage to
canister-stamped credential metadata

`ssoDomainStorage.ts` (a per-device localStorage map of `(iss, sub, aud)
→ domain`) is removed. The canister now stamps two new metadata keys on
any credential verified by a `DiscoverableProvider`:

- `sso_domain` — the `discovery_domain` the user entered at sign-up. The
canonical SSO label; always present for SSO credentials.
- `sso_name` — optional human-readable name from
`{domain}/.well-known/ii-openid-configuration`. When the domain
publishes `name: "DFINITY"`, the access-methods list reads "DFINITY
account" instead of "dfinity.org account".

BE:
- `IIOpenIdConfiguration` hop-1 schema gets `name: Option<String>` (with
`#[serde(default)]` so older deployments still parse).
- `DiscoverableProvider` and `DiscoveryState` carry `discovery_domain`
and a `discovered_name` ref that hop-1 populates each refresh.
- `DiscoverableProvider::verify()` inserts the two keys before returning
the credential.

FE:
- `openIdName` now resolves `sso_name` → `sso_domain` → `findConfig(iss,
aud, metadata).name`, so an SSO-via-Google credential reads as "DFINITY"
(or "dfinity.org" if the domain doesn't publish a name), not "Google".
- `openIdLogo` returns `undefined` when `sso_domain` is present (generic
SSO icon).
- `OpenIdItem.svelte` detects SSO via `sso_domain` directly, instead of
the previous "no logo found" heuristic that was brittle for
direct-provider credentials whose issuer didn't match any
`openid_configs` entry.
- `authFlow#continueWithSso` and `addAccessMethodFlow#linkSsoAccount`
both simplify — no pre-decoding the JWT or remembering the domain
locally — because the canister handles the labeling end-to-end, so the
mapping survives reloads and crosses devices.

# Tests

- `cargo test -p internet_identity --bin internet_identity`: **219/219
pass**.
- `cargo clippy --all-targets -D warnings`: clean.
- `cargo fmt --check`: clean.
- `npm run check` (tsc + svelte-check): 0 errors (19 pre-existing
warnings unchanged).
- `npm run lint` + `prettier --check`: clean.

---
[< Previous PR](dfinity#3785)

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants